feat: tab group Vomnibar (zg / ZG), multi-tab selection, color swatches#4914
Open
MarvinHauke wants to merge 17 commits into
Open
feat: tab group Vomnibar (zg / ZG), multi-tab selection, color swatches#4914MarvinHauke wants to merge 17 commits into
MarvinHauke wants to merge 17 commits into
Conversation
3964651 to
b11ffcb
Compare
- za: collapse current tab group (was zc) - zn/zN: next/previous tab group with circular wrap (were zk/zj) - goToTabGroup now wraps around to the first/last group when no target exists in the current direction - add .serena to .gitignore Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Previously, << and >> (moveTabLeft/moveTabRight) moved a tab by a fixed number of positions in the tab strip, ignoring tab group structure entirely. This rewrite introduces group-aware movement where tab groups behave as atomic blocks: - Moving INTO an open group: if the neighboring tab belongs to an expanded group, the tab joins that group via chrome.tabs.group(). Chrome keeps the tab at its current adjacent position and adds it as a member, so no explicit repositioning is needed. - Moving past a collapsed group: if the neighboring tab belongs to a collapsed group, the tab skips over the entire group and lands immediately on the far side. The target index is computed as afterGroup.index - direction to account for the index shift that occurs when Chrome removes the tab from its original position before reinserting it. - Exiting a group at its boundary: if the active tab is the first/last member of a group and the user presses << or >> toward the boundary, chrome.tabs.ungroup() removes it from the group without any additional move call — the tab stays at its current index, which is already outside the remaining group members. - Moving within a group (not at a boundary): behaves identically to the previous implementation, shifting one position at a time. For counts > 1 (e.g. 3>>) the logic is applied iteratively, re-fetching the tab after each step to get the updated index. Fallback: pinned tabs and environments where chrome.tabGroups is unavailable (e.g. older browsers) retain the original flat-index logic unchanged. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…leter.js
## Motivation
main.js was growing too large as tab group features accumulated. All group-related
background logic is now consolidated in a dedicated module, following the same pattern
used by marks.js, exclusions.js, and tab_operations.js.
## Changes
### background_scripts/tab_groups.js (new)
Exports four public BackgroundCommand handlers:
- collapseTabGroup: collapse the current tab's group, moving focus to an adjacent tab
- previousTabGroup / nextTabGroup: circular navigation between groups
- moveTab: group-aware implementation of << and >> (replaces the inline version in main.js)
Two private helpers (goToTabGroup, moveTabOneStep) remain internal to the module.
The only external dependency is bg_utils.js (for isFirefox()). Circular imports are
avoided by inlining the two main.js helpers that were previously needed:
- visibleTabsQueryArgs → { currentWindow: true } (pinned tabs are never hidden)
- getTabIndex(tab, tabs) → tabs.findIndex((t) => t.id === tab.id) (id-based, safer)
### background_scripts/completion/group_completer.js (new)
Implements TabGroupCompleter following the Completer contract from completers.js
(filter({ queryTerms }) → Suggestion[]). Queries chrome.tabGroups for the current
window, maps each group to a Suggestion with tabId set to the first tab in the group
so Vomnibar can jump to it on selection. Falls back gracefully when chrome.tabGroups
is unavailable (Firefox without tab group support).
### background_scripts/main.js
- Added imports for tab_groups.js and group_completer.js
- Added tabGroups entry to completionSources and completers (key: "tabGroups")
- Delegated collapseTabGroup, previousTabGroup, nextTabGroup, moveTabLeft, moveTabRight
to the tabGroups module
- Removed the now-redundant inline implementations of moveTab, moveTabOneStep,
collapseTabGroup, previousTabGroup, nextTabGroup, goToTabGroup (~80 lines removed)
### content_scripts/vomnibar.js
Added activateTabGroupSelection() method with completer: "tabGroups", selectFirst: true.
### background_scripts/all_commands.js
Added Vomnibar.activateTabGroupSelection command definition.
### background_scripts/commands.js
Added "ZG" key binding for Vomnibar.activateTabGroupSelection.
Note: ZG is a stub — the full group management UI (creating groups, adding selected
tabs to groups) will be wired up in a follow-up commit.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Introduces two new background commands that extend Chrome's native tab
highlighting to allow selecting multiple tabs at once — a prerequisite for
the upcoming "group selected tabs" feature (zg).
## How it works
Chrome maintains a "highlighted" state on tabs independently of the active
tab. chrome.tabs.highlight({ windowId, tabs: [indices] }) replaces the
highlighted set while keeping the specified active tab as the first entry.
The highlighted tabs appear visually selected (blue) in Chrome's native tab
strip, with no content-script UI changes needed.
## Behavior
ctrl+shift+j (bound as <c-J>): adds the tab immediately after the highest
currently-highlighted index to the selection. Stops at the last tab.
ctrl+shift+k (bound as <c-K>): adds the tab immediately before the lowest
currently-highlighted index to the selection. Stops at the first tab.
The active tab index is always kept in the highlighted set as the first
element (Chrome requirement — removing it from the set would change the
active tab).
## Files
- background_scripts/tab_groups.js: selectNextTabForGroup,
selectPreviousTabForGroup exported and implemented
- background_scripts/main.js: two new BackgroundCommands entries
- background_scripts/all_commands.js: two new command definitions
- background_scripts/commands.js: <c-J> and <c-K> key bindings
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
ctrl+shift+j (<c-J>) is intercepted by Chrome on Windows and Linux as the DevTools Console shortcut, preventing Vimium's content script from ever seeing the keydown event. alt+shift+j/k (<a-J> / <a-K>) is not reserved by Chrome on any platform and reliably reaches the content script. Key encoding in Vimium: shift on a single-char key uppercases the char (j → J), and alt adds the "a" modifier prefix → <a-J>. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Rebind selectNextTabForGroup to `zz` and selectPreviousTabForGroup to `ZZ` (was <a-J>/<a-K>, which Karabiner Elements intercepts on macOS). Both shortcuts now sit cleanly in the z-prefix group alongside za/zn/zN. Rewrite both functions with vim visual-mode semantics: tab.index is the fixed anchor; the selection cursor extends or shrinks relative to it. - zz: extends right if right side is selected; shrinks left if left side is selected; starts extending right if nothing is selected yet. - ZZ: extends left if left side is selected; shrinks right if right side is selected; starts extending left if nothing is selected yet. Also switches chrome.tabs.query from currentWindow:true to windowId:tab.windowId for reliable results from MV3 service workers, and shortens the command descriptions to "Select next/previous tab". Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
## New commands
- `ZG` — navigate to a tab group: opens Vomnibar listing all groups in
the current window; selecting one uncollapses the group and switches
to its first tab.
- `zg` — assign highlighted tabs to a group: two-step Vomnibar flow:
1. Shows all existing groups; type to filter or enter a name.
- Enter on an exact name match → assigns highlighted tabs to that group.
- Enter on a non-matching name → moves to step 2 (color picker).
2. Shows the 9 Chrome tab group colors; selecting one creates a new
named, colored group containing the highlighted tabs.
Selecting an existing group directly from the dropdown assigns tabs
immediately without opening step 2.
## Completers (group_completer.js)
- `TabGroupCompleter` (ZG): lists groups with `showResultsWithNoQuery`
so they appear as soon as the Vomnibar opens.
- `TabGroupAssignCompleter` (zg step 1): same, plus emits a trailing
"Create…" suggestion when the query is non-empty.
- `TabGroupColorCompleter` (zg step 2): lists all 9 Chrome colors with
`showResultsWithNoQuery`.
- All three use `chrome.windows.getLastFocused()` for the window ID —
`WINDOW_ID_CURRENT` / `currentWindow: true` are unreliable in MV3
background service workers.
- Every suggestion pre-sets `s.html` so `generateHtml()` returns it
directly, embedding a colored swatch (`<span class="group-color-swatch">`)
inline-styled with the actual Chrome group color hex.
## Suggestion class (completers.js)
- Declared `groupData` as a class field alongside the existing `html`,
`searchUrl`, etc. — required because the constructor calls
`Object.seal(this)`, preventing ad-hoc property additions.
- Extended `MultiCompleter.filter`'s empty-query guard to also allow
completers that set `showResultsWithNoQuery = true`, not just
`TabCompleter`.
## Vomnibar page (vomnibar_page.js)
- `VomnibarUI` constructor: added `this.pendingGroupName` to carry the
group name across the two-step flow (survives `reset()`, which does
not clear it).
- `handleEnterKey`: smart Enter for `tabGroupAssign` — exact title
match assigns; anything else enters step 2 (color picker).
- `handleOpenRequested`: when the selected completion is a "createGroup"
action, switches completer to `groupColors` in-place instead of
closing the Vomnibar.
- `openCompletion`: dispatches on `groupData.action` first
(`addToGroup`, `setColor`), then falls back to `tabId != null` for
tab switching (replacing the fragile `description == "tab"` check that
broke ZG), then URL launch.
## Background (main.js)
- Added `addTabsToGroup` handler: groups all highlighted tabs in the
focused window into an existing group.
- Added `createTabGroupWithColor` handler: groups highlighted tabs into
a new group and sets its title and color.
- `selectSpecificTab`: uncollapses the tab's group before switching to
it, so the ZG destination is always visible.
## Wiring (mode_normal.js, vomnibar.js, commands.js, all_commands.js)
- Added `Vomnibar.activateTabGroupSelection` and
`Vomnibar.activateGroupAssign` to the manually maintained
`NormalModeCommands` dispatch table — both were missing, causing `ZG`
and `zg` to silently do nothing.
- Registered both methods on the `Vomnibar` object in `vomnibar.js`.
- Bound `zg` key in `commands.js` and declared both commands in
`all_commands.js`.
## CSS (vomnibar_page.css)
- Added `.group-color-swatch`: 10×10px rounded square, vertically
centered, right margin 5px. Background is always set inline from the
`COLOR_CSS` map in `group_completer.js`, so swatches work in both
light and dark Vomnibar themes without any media query override.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Covers the two most logic-heavy parts of the tab group feature: - tests/unit_tests/tab_groups_test.js (10 cases) selectNextTabForGroup (zz) and selectPreviousTabForGroup (ZZ): extend, shrink, and no-op behavior for all anchor/selection combos. - tests/unit_tests/group_completer_test.js (16 cases) TabGroupCompleter: empty-query results, title/color filtering, tabId assignment, color swatch hex in html, unnamed group fallback title. TabGroupAssignCompleter: no Create entry on empty query, Create entry appended on non-empty query, correct groupId, filtering by title. TabGroupColorCompleter: all 9 colors returned, prefix filtering, setColor action on every result, GROUP_COLORS order preserved, swatch hex in html. Also removes the redundant bare import of completers.js in main.js (already loaded by the named import below it) and expands the tab group completer import to multi-line style for consistency. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Previously, pressing >> on a free tab when a collapsed group was the last item in the tab bar silently did nothing (the function returned early because there was no tab after the group to anchor against). The user had to manually uncollapse the group, then shift through every tab inside it to reach the end — unintuitive. The fix: when afterGroup is undefined (group is flush against the window edge), move the tab to the edge group tab's index instead of returning. The index arithmetic is the same as the non-edge case; the group shifts by one to accommodate the incoming tab. Open-group behaviour (>> absorbs the tab into the group) is unchanged. Also adds two unit tests covering both the right-edge and left-edge cases via the public moveTab function. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
3zz / 3ZZ now extend or shrink the visual tab selection by N tabs in one keystroke, matching the existing count behavior of >> / <<. Remove noRepeat from both commands so the count prefix reaches the handler, then loop the single-step logic N times via private helper functions. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When multiple tabs are highlighted (via zz/ZZ), pressing >> or << now moves the entire selection block together as a unit. Moves the adjacent outside tab to the opposite side of the block — one swap per step, count supported. Extract all selection logic (selectNextTabForGroup, selectPreviousTabForGroup, moveTabSelection) into a new tab_selection.js module. tab_groups.js stays focused on group-aware single-tab movement and delegates to moveTabSelection when multiple non-pinned tabs are highlighted. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When the selection block's neighbor belongs to a tab group, the previous swap approach ripped just one tab out of the group. Now: - Collapsed group → treat it as a single unit; move the entire selection block past the whole group in one step (chrome.tabs.move with an array of selected IDs, targeting groupTabs[0].index). - Open group → add all selected tabs to the group without ripping it apart (chrome.tabs.group). - No group (existing behavior) → simple neighbor swap, unchanged. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The previous approach moved the selected tabs to groupTabs[0].index
(inside the group range), which caused Chrome to unfold the collapsed
group and insert the selection into it.
Fix: move the ENTIRE collapsed group to selected[0].index instead.
Chrome slides the group to the other side of the selection block while
keeping it collapsed and intact. This is safe for both directions since
{ index: selected[0].index } always targets a position outside the group.
Also fix intra-group movement: when the neighbor shares a groupId with
any selected tab, fall through to a simple swap so tabs inside a group
can be reordered normally.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Rewrites moveSelectionOneStep to correctly handle all tab group
interactions when moving a multi-tab selection with >> / <<.
Previous behaviour had three bugs:
1. Skipping a collapsed group moved the GROUP tabs, not the selected
tabs. This ripped the group apart: chrome.tabs.move processes an
array sequentially, so the first group tab would land between
remaining group members, causing Chrome to ungroup it.
2. The sameGroup check used selected.some() instead of checking whether
the ENTIRE selection shared one group, so a selection that happened
to touch a group member incorrectly triggered the intra-group path.
3. The selection had no special handling when it was already inside a
group, so pressing >> / << from inside a group would pull in an
outside tab rather than moving within or exiting the group.
New behaviour:
Selection NOT in a group:
- Ungrouped neighbor → swap as before
- Collapsed group neighbor → move the SELECTION past the group using
sequential individual moves (rightmost-first for >>, leftmost-first
for <<). Moving the selection rather than the group keeps the group
fully intact; the direction-dependent order ensures each tab always
lands just outside the group rather than between two group members.
- Open group neighbor → join the selection into the group via
chrome.tabs.group, leaving existing group members untouched.
Selection entirely inside one group:
- Next neighbor is in the same group → swap within the group (move
the neighbor to the opposite edge of the selection).
- Next neighbor is outside the group → exit the group one step:
- Exiting right (>>): ungroup rightmost-first. Chrome places each
ungrouped tab just after the last remaining group member; going
rightmost-first means every tab is already that last member, so
Chrome leaves it in place and order is preserved.
- Exiting left (<<): ungroup leftmost-first. Symmetric reasoning —
Chrome places each ungrouped tab just before the first remaining
group member; going leftmost-first keeps each tab in place.
- Non-selected group members are never ungrouped or repositioned.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…story
za and zA previously picked the tab to land on after collapsing a group
by nearest index, which often dropped you somewhere arbitrary rather
than back where you actually came from.
- Add bgUtils.getLastActiveTab({ windowId, excludeTabId, isValid }) in
background_scripts/bg_utils.js. It walks the existing TabRecency
history (bgUtils.tabRecency, already used by ^ / visitPreviousTab)
filtered by window and a caller-supplied predicate, returning the
most recently active matching tab or null.
- Rewire collapseTabGroup (za) to prefer the last-active tab outside
the group being collapsed, falling back to the previous
index-proximity search, then to opening a new tab, if no candidate
is found via recency.
- Add collapseAllTabGroups (zA): collapses every currently-expanded
group in the window, then jumps to the last-active ungrouped tab
using the same recency -> index-proximity -> new-tab fallback chain.
Wired up via background_scripts/commands.js (key mapping),
all_commands.js (registry entry), and main.js (dispatch).
- za/zA activate the target tab directly via chrome.tabs.update rather
than reusing selectSpecificTab, since that helper re-expands the
target's group if grouped -- which would undo the collapse we just
did.
- Add unit tests in tests/unit_tests/bg_utils_test.js and
tests/unit_tests/tab_groups_test.js covering the recency / index /
new-tab fallback chain for both commands.
- Update TODO.md: the previously-proposed "tab history" feature turned
out to already exist as TabRecency/visitPreviousTab, just not
documented under that name; the doc now points at the real
implementation instead of describing a redundant per-window stack,
and records the za/zA work above as implemented.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
c931ada to
191d0e6
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Related
Builds on / complements #4858, which adds
zc/zj/zknavigation and skipping of collapsed groups. This PR adds higher-level group management via the Vomnibar and visual multi-tab selection.New commands
ZGVomnibar.activateTabGroupSelectionzgVomnibar.activateGroupAssignzacollapseTabGroupzn/zNnextTabGroup/previousTabGroupzz/ZZselectNextTabForGroup/selectPreviousTabForGroupFeature details
ZG— navigate to a tab groupOpens the Vomnibar listing all groups in the current window. Selecting one uncollapses the group and switches to its first tab. Groups appear immediately without needing to type (no-query results).
zg— assign highlighted tabs to a group (two-step flow)zz/ZZ— multi-tab visual selectionVim visual-mode semantics: the current tab is the anchor.
zzextends the selection right (or shrinks from the left if already extended left).ZZextends the selection left (or shrinks from the right).Use together with
zgto assign multiple tabs to a group in one flow.Color swatches in Vomnibar
Group suggestions show a small colored square next to the color name so groups and colors are visually distinguishable at a glance — both in the group list (ZG / zg step 1) and the color picker (zg step 2). Works in light and dark Vomnibar themes.
Implementation notes
chrome.windows.getLastFocused()for the window ID —WINDOW_ID_CURRENTandcurrentWindow: trueare unreliable in MV3 background service workers.Suggestionhas a newgroupDataclass field (required because the constructor callsObject.seal(this)).MultiCompleter.filter's empty-query guard is extended with ashowResultsWithNoQueryopt-in flag used by all three group completers.background_scripts/tab_groups.js; completers inbackground_scripts/completion/group_completer.js.🤖 Generated with Claude Code